03 - 预训练主循环
这一篇产出一个 train.py。跑起来 loss 会从 10.37 开始往下掉,能存 checkpoint,能断点续训,屏幕上能看到 MFU。
刻意不用 Trainer,不用 Accelerate。整个循环三十行以内看得完,每一行在干什么都说得清。
前置:01 篇产出的 train.bin 和 val.bin,02 篇产出的 model.py。这一篇还是单卡,多卡放到 04 篇。
零、开始之前:训练循环在循环什么
0.1 五个动作
训练就是把下面五步重复几千次,没有别的:
02 篇已经把第 2 步(前向和 loss)写完了,01 篇把第 1 步的数据准备好了。这一篇要补的是第 3 到 5 步,以及一堆让这个循环真的能跑完的工程细节。
0.2 为什么不是三十行就结束了
理论上确实三十行就够。但直接这么写,会遇到四个问题:
显存不够。 想要大 batch 让训练稳定,但一次前向放不下那么多样本。对策是梯度累积(第三节)。
太慢。 全用 fp32 算,速度只有一半不到。对策是混合精度(第二节)。
训不稳。 学习率从一开始就拉满,前几十步梯度会炸。对策是 warmup 加梯度裁剪(第五、六节)。
跑一半挂了。 租的实例被回收,17 小时白跑。对策是 checkpoint(第八节)。
这一篇剩下的内容基本就是在解决这四件事。
0.3 这一篇要定的超参
先把结论摆着 ,后面每一节解释一个:
| 超参 | 取值 | 在哪一节 |
|---|---|---|
| 精度 | bf16 混合精度 | 第二节 |
micro_batch | 8 | 第三节 |
grad_accum | 16 | 第三节 |
| 优化器 | AdamW,β=(0.9, 0.95) | 第四节 |
weight_decay | 0.1,只加在 2 维以上参数 | 第四节 |
| 学习率 | 3e-4,warmup 2% 后 cosine 降到 3e-5 | 第五节 |
grad_clip | 1.0 | 第六节 |
一、取数据
1.1 直接用 memmap,不用 DataLoader
01 篇 5.2 节写过 get_batch,这里补上搬到 GPU 的部分:
def get_batch(data, batch_size, seq_len, device):
ix = np.random.randint(len(data) - seq_len - 1, size=batch_size)
x = torch.from_numpy(np.stack([data[i:i + seq_len].astype(np.int64) for i in ix]))
y = torch.from_numpy(np.stack([data[i + 1:i + 1 + seq_len].astype(np.int64) for i in ix]))
if device.type == "cuda":
x = x.pin_memory().to(device, non_blocking=True)
y = y.pin_memory().to(device, non_blocking=True)
else:
x, y = x.to(device), y.to(device)
return x, y
为什么不用 DataLoader。 DataLoader 解决的是「样本长度不一、要 shuffle、要 collate」这些问题。而我们的数据已经是一个扁平的 token 流,取一批就是随机取几个起点做切片,没有任何需要 collate 的东西。用 DataLoader 反而要多绕一层 Dataset 抽象,还要处理多进程 worker 和 memmap 的配合,收益是负的。
pin_memory 和 non_blocking 是一对。 普通内存里的张量拷到 GPU,CUDA 得先把它复制到一块锁页内存再传,这个过程是同步的,会卡住 CPU。pin_memory() 直接把张量分配在锁页内存里,配合 non_blocking=True 就能让这次 H2D 拷贝异步进行,跟 GPU 上的计算重叠。
两个必须一起用。只写 non_blocking=True 而内存没锁页,PyTorch 会静默退回同步拷贝,不报错也没有加速。
1.2 随机取起点意味着不存在 epoch
因为每一步都是随机取起点,训练过程中没有「跑完一轮数据」这个概念,也就没有 epoch。总量由步数控制,跑够 9537 步就停。
好处是不用管数据顺序,也不用 shuffle。代价是同一段文本可能被看到不止一次、也可能一次都没被完整看到。在 10B token 这个量级上,这个偏差可以忽略。
二、混合精度
2.1 为什么不全用 fp32
fp32 是 32 位浮点,精度足够,但有两个问题:占显存(每个数 4 字节),而且现代 GPU 的低精度算力远高于 fp32。A100 的 fp32 峰值只有 19.5 TFLOPS,而 BF16 走 Tensor Core 是 312 TFLOPS,差 16 倍。
不用低精度,等于买了张 A100 只用了十六分之一。
2.2 fp16 和 bf16 的区别
两种 16 位浮点,位数怎么分配不一样:
| 格式 | 符号位 | 指数位 | 尾数位 | 能表示的范围 | 精度 |
|---|---|---|---|---|---|
| fp32 | 1 | 8 | 23 | 约 ±3.4e38 | 高 |
| fp16 | 1 | 5 | 10 | 约 ±65504 | 中 |
| bf16 | 1 | 8 | 7 | 约 ±3.4e38 | 低 |
关键在于 bf16 的指数位和 fp32 一样多,所以它能表示的数值范围跟 fp32 相同,只是精度低。fp16 反过来,精度尚可但范围窄得多,最大只到 65504。
训练时梯度经常是很小 的数(1e-8 量级),fp16 直接下溢成 0,梯度就没了。所以 fp16 训练必须配一个 GradScaler:把 loss 乘上一个大系数,让梯度落进 fp16 能表示的区间,反向之后再除回来。这套机制要动态调整系数,遇到 inf 还要跳过这一步,很麻烦。
bf16 不需要 GradScaler,因为它的动态范围和 fp32 一样,梯度再小也不会下溢。代价是精度低,但训练对精度不敏感(参数更新本来就是在 fp32 的 master weights 上做的)。
结论:Ampere(A100、3090)及以后的卡一律用 bf16。只有更老的卡(V100、T4)没有 bf16 支持,才需要退回 fp16 加 GradScaler。
2.3 代码
use_bf16 = device.type == "cuda" and torch.cuda.is_bf16_supported()
ctx = (torch.autocast(device_type="cuda", dtype=torch.bfloat16)
if use_bf16 else nullcontext())
with ctx:
_, loss = model(x, y)
loss.backward()
注意 backward() 在 with 块外面。autocast 只需要覆盖前向。反向传播时 PyTorch 会自动按前向记录的 dtype 来算,把 backward() 放进去不会出错但没有意义。
2.4 autocast 不是把所有东西都转成 bf16
autocast 内部维护了一张算子白名单。矩阵乘法、卷积这类计算密集又对精度不敏感的,走 bf16。而 softmax、layer_norm、各种 reduction 和 loss 计算,会自动保持 fp32,因为它们涉及累加,低精度容易丢有效数字。
这也是 02 篇 RMSNorm 里为什么要手动写 x.float():那段是自己实现的,不在 autocast 的白名单机制里,得自己保证统计量用 fp32 算。
三、梯度累积
3.1 想要大 batch,但显存装不下
batch 越大,梯度的估计越准,训练越稳。大模型预训练的 batch 通常在百万 token 量级。
但一次前向能放多少样本,是硬性受显存限制的。02 篇算过静态显存 8 GB,剩下的都给激活值,micro_batch 只能开到 8 左右。8 × 2048 = 16384 token,离百万差着两个数量级。
3.2 做法:先攒够梯度再更新
梯度累积的思路很简单:跑多次前向反向,梯度自然累加在 .grad 里,攒够了再调用一次 optimizer.step()。
这利用了 PyTorch 的一个默认行为:backward() 是把梯度加到 .grad 上,不是覆盖。平时我们每步都 zero_grad() 所以感觉不到,把 zero_grad() 挪到累积结束后,就自动实现了累积。
for micro in range(args.grad_accum):
x, y = get_batch(train_data, args.micro_batch, args.seq_len, device)
with ctx:
_, loss = model(x, y)
loss = loss / args.grad_accum # 关键,见 3.3
loss.backward()
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
3.3 loss 必须除以累积次数
这行 loss = loss / args.grad_accum 少了会怎样,值得说清楚。
正常的 batch 是对 N 个样本的 loss 求平均。梯度累积跑 16 次,每次的 loss 已经是各自 8 个样本的平均,直接累加得到的是 16 个平均值的和,比真实平均大了 16 倍。梯度也跟着大 16 倍。
后果是等效学习率被放大了 16 倍。你以为在用 3e-4,实际是 4.8e-3,训练几十步就发散。
这个 bug 很隐蔽,因为代码不报错,loss 曲线也不是立刻爆炸,而是训一阵子突然变 NaN。
zero_grad(set_to_none=True) 是把 .grad 置为 None 而不是填 0,省一次显存写入,也省一点显存。这是现在的推荐写法。
3.4 batch 的账
把数算一遍。code/train_schedule.py 的实际输出:
批次账
每卡一次前向 8 条 × 2048 = 16,384 tokens
累积 16 次 262,144 tokens/卡/步
× 4 卡 1,048,576 tokens/步
总步数 10,000,000,000 / 1,048,576 = 9,537 步
warmup 190 步(2%)
每步 1,048,576 个 token,正好一百万出头,这跟主流小模型预训练的 batch 规模是一致的。总共 9537 步。
注意这里已经把 4 卡算进去了。单卡跑的话每步是 262,144 token,总步数变成 38,147 步,但总 token 数不变。卡数只影响步数,不影响看到的数据量。
四、优化器
4.1 AdamW,β2 取 0.95
用 AdamW,两个 β 值跟默认不太一样:
torch.optim.AdamW(groups, lr=3e-4, betas=(0.9, 0.95), fused=True)
beta2 默认是 0.999,LLM 训练普遍改成 0.95。beta2 控制二阶动量(梯度平方的滑动平均)的窗口长度,0.999 大致是看最近一千步,0.95 大致是看最近二十步。语言模型的梯度分布变化比较快,窗口短一些反应更及时,实践中更稳。
fused=True 把优化器的逐元素运算融合成一个 kernel。参数张量多的时候(我们有一百多个),能省下可观的 kernel 启动开销。只在 CUDA 上可用。
4.2 weight decay 不能一视同仁
这是个容易写错的地方。weight_decay 会把参数往 0 拉,起正则化作用。但不是所有参数都该被拉向 0。
RMSNorm 的 缩放系数 weight 初始化是 1,含义是「不做缩放」。把它往 0 拉,等于强行削弱归一化的输出,纯属帮倒忙。各种 1 维参数(bias、norm 系数)都是同理。
通行做法是按维度分组:
def make_optimizer(model, args):
decay, no_decay = [], []
for _, p in model.named_parameters():
if not p.requires_grad:
continue
(decay if p.dim() >= 2 else no_decay).append(p)
groups = [
{"params": decay, "weight_decay": args.weight_decay},
{"params": no_decay, "weight_decay": 0.0},
]
return torch.optim.AdamW(groups, lr=args.lr,
betas=(args.beta1, args.beta2),
fused=torch.cuda.is_available())
按维度分而不是按名字分,是因为维度是个更可靠的判据:所有权重矩阵都是 2 维以上,所有 norm 系数和 bias 都是 1 维。按名字匹配容易漏掉改过名的层。
我们的模型没有 bias(02 篇 2.3 节),所以 no_decay 组里只有 37 个 RMSNorm 的系数(18 层 × 2 + 最后 1 个),参数量很少。但该分还是要分。